Skip to content

fix(instant): make forced conversion sheet non-dismissible#125

Merged
mhamann merged 2 commits into
mainfrom
fix/instant-user-conversion-non-dismissible
May 12, 2026
Merged

fix(instant): make forced conversion sheet non-dismissible#125
mhamann merged 2 commits into
mainfrom
fix/instant-user-conversion-non-dismissible

Conversation

@mhamann
Copy link
Copy Markdown
Contributor

@mhamann mhamann commented May 12, 2026

Summary

When Rownd.config.forceInstantUserConversion is enabled, the SDK now actually forces the sign-in sheet to stay open until the user adds an identifier. Previously the sheet defaulted to dismissible by swipe, tap-outside, and any Hub-dispatched close message — so customers with large populations of instant users would see the prompt and dismiss it without converting.

Reported impact: ~8,000 active instant users that never converted, on at least one customer.

What changed

  • BottomSheetViewController gains an isUserDismissalDisabled flag. When set before presentation, viewWillAppear calls setCanTouchDimmingBackgroundToDismiss(false) after the LBBottomSheet is created (disables both swipe-to-dismiss and tap-outside-to-dismiss). The flag is reset on viewDidDisappear. The existing canTouchDimmingBackgroundToDismiss(_:) now refuses re-enable calls from the Hub while the lock is active.
  • hideBottomSheet and HubViewController.hide() both honor the same lock, so the Hub's closeHubViewController and signOut messages cannot close the sheet either.
  • Rownd.requestSignInForcedConversion(_:) / Rownd.releaseForcedConversionLock() — internal helpers to set/clear the lock.
  • InstantUsers now keeps its subscription alive past the initial trigger. When the user's authLevel transitions out of .instant (to .verified, .unverified, or .guest), it releases the lock so the Hub's post-success auto-close (hide() 1.5s after the .authentication message) proceeds normally. .unknown is treated as a transient state and does not release.

No Hub-side change required

The fix is iOS-only. Existing customers don't need to update their Hub configuration — flipping forceInstantUserConversion on the iOS side is sufficient.

Test plan

  • Verify with forceInstantUserConversion = false (default): sheet behavior is unchanged — swipe, tap-outside, and Hub close messages all dismiss as before.
  • Verify with forceInstantUserConversion = true:
    • Sheet appears when the user enters an authenticated .instant state.
    • Swipe-down does not dismiss.
    • Tap-outside does not dismiss.
    • Any Hub-side close button (closeHubViewController message) does not dismiss.
    • On successful sign-in (email, Apple, Google, passkey), the sheet auto-closes once authLevel transitions to non-.instant.
  • Confirm the Hub's in-sheet "X" / close affordance, if rendered, is the only remaining escape (this PR cannot block Hub-internal UI — flag separately if customers need that too).
  • Build the example app (landmarks scheme) on an iOS 18 simulator with the flag set — verified locally.

Notes

  • The Hub's web UI may still render its own close button inside the sheet; that's out of scope here. If a customer needs that hidden too, it should be addressed in the Hub.
  • The lock is process-local on the singleton BottomSheetViewController and self-resets on viewDidDisappear, so it cannot leak across app launches.

🤖 Generated with Claude Code

Summary by Sourcery

Enforce non-dismissible sign-in bottom sheet for instant users when forced conversion is enabled, and automatically release the lock after successful conversion.

New Features:

  • Introduce a forced-conversion sign-in flow that prevents user-initiated dismissal of the bottom sheet for instant users until they add a sign-in method.

Enhancements:

  • Add a dismissal lock on the shared bottom sheet controller that blocks swipe, tap-outside, and Hub-triggered closes while forced conversion is active.
  • Keep the instant user subscription alive to detect auth level transitions and release the dismissal lock once the user is no longer an instant user.

When `forceInstantUserConversion` is enabled, the sign-in sheet must be
non-dismissible until the user actually adds an identifier. Previously
the sheet defaulted to dismissible via swipe, tap-outside, and any Hub
close message, which left customers with large populations of unconverted
instant users.

- BottomSheetViewController gains an `isUserDismissalDisabled` flag that
  disables swipe-to-dismiss and tap-outside-to-dismiss at presentation,
  and that the Hub's `can_touch_background_to_dismiss` message cannot
  re-enable.
- `hideBottomSheet` and `HubViewController.hide()` honor the same lock,
  so Hub-dispatched `closeHubViewController`/`signOut` messages cannot
  close the sheet either.
- InstantUsers observes the post-conversion auth-level transition and
  releases the lock once the user is no longer `.instant`, so the
  standard post-auth auto-close path still runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 12, 2026

Reviewer's Guide

Implements a forced-conversion lock for the Rownd sign-in bottom sheet so that, when configured, instant users cannot dismiss the sheet until they add a non-instant identifier, while ensuring the lock is automatically released after successful conversion and does not leak across sessions.

Sequence diagram for forced conversion lock on instant user sign-in sheet

sequenceDiagram
    actor InstantUser
    participant InstantUsers
    participant Rownd
    participant BottomSheetViewController
    participant HubViewController

    InstantUser->>InstantUsers: authLevelPublisher (isAuthenticated=true, authLevel=instant)
    InstantUsers->>Rownd: requestSignInForcedConversion(signInOptions)
    Rownd->>BottomSheetViewController: isUserDismissalDisabled = true
    Rownd->>Rownd: requestSignIn(signInOptions)
    Rownd->>BottomSheetViewController: presentAsBottomSheet(controller, behavior)
    BottomSheetViewController->>BottomSheetViewController: viewWillAppear()
    BottomSheetViewController->>BottomSheetViewController: setCanTouchDimmingBackgroundToDismiss(false)

    par User attempts to dismiss
        InstantUser->>BottomSheetViewController: hideBottomSheet()
        BottomSheetViewController->>BottomSheetViewController: [isUserDismissalDisabled]
        BottomSheetViewController-->>InstantUser: ignore hideBottomSheet

        InstantUser->>HubViewController: closeHubViewController
        HubViewController->>HubViewController: hide()
        HubViewController->>HubViewController: [bottomSheetController.isUserDismissalDisabled]
        HubViewController-->>InstantUser: ignore hide

        InstantUser->>BottomSheetViewController: canTouchDimmingBackgroundToDismiss(true)
        BottomSheetViewController->>BottomSheetViewController: [isUserDismissalDisabled]
        BottomSheetViewController-->>InstantUser: ignore enable
    end

    InstantUser->>InstantUsers: authLevelPublisher (authLevel=verified)
    InstantUsers->>Rownd: releaseForcedConversionLock()
    Rownd->>BottomSheetViewController: isUserDismissalDisabled = false

    HubViewController->>HubViewController: hide()
    HubViewController->>BottomSheetViewController: hideBottomSheet()
    BottomSheetViewController->>BottomSheetViewController: dismiss()
    BottomSheetViewController-->>HubViewController: completion
    HubViewController-->>InstantUser: dismiss(animated=true)
Loading

File-Level Changes

Change Details Files
Add a process-local forced-conversion lock on the bottom sheet to disable user-driven dismissal while allowed programmatic closes remain functional.
  • Introduce an isUserDismissalDisabled flag on BottomSheetViewController that, when set, disables swipe and tap-outside dismissal via setCanTouchDimmingBackgroundToDismiss(false) during viewWillAppear.
  • Reset isUserDismissalDisabled to false in viewDidDisappear so the lock does not survive sheet lifecycle or app relaunches.
  • Block hideBottomSheet() when isUserDismissalDisabled is true, logging and returning early instead of dismissing.
  • Prevent canTouchDimmingBackgroundToDismiss(_:) from re-enabling background-touch dismissal while the forced-conversion lock is active.
Sources/Rownd/Views/BottomSheetViewController.swift
Expose internal helpers on Rownd to acquire and release the forced-conversion lock around sign-in requests.
  • Add requestSignInForcedConversion(:) to set bottomSheetController.isUserDismissalDisabled before delegating to requestSignIn( :).
  • Add releaseForcedConversionLock() to clear bottomSheetController.isUserDismissalDisabled on the singleton instance.
Sources/Rownd/Rownd.swift
Update HubViewController and InstantUsers to coordinate forced instant-user conversion and release the lock once the user’s auth level transitions away from .instant.
  • Guard HubViewController.hide() so it no-ops with a debug log when bottomSheetController.isUserDismissalDisabled is true, preventing Hub-dispatched close messages from dismissing the sheet during forced conversion.
  • Track a hasTriggeredConversion flag in InstantUsers to ensure the conversion sheet is requested once per process, switching to a long-lived subscription instead of auto-unsubscribing after the first trigger.
  • Use Rownd.requestSignInForcedConversion(_:) when authLevel becomes .instant to both present the sheet and enable the forced-conversion lock.
  • On subsequent authLevel changes, call Rownd.releaseForcedConversionLock() and unsubscribe once the user leaves the .instant state (excluding .unknown), allowing the Hub’s post-success auto-close to proceed.
Sources/Rownd/Views/HubViewController.swift
Sources/Rownd/framework/InstantUsers.swift

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Make forced instant user conversion sheet non-dismissible until conversion

🐞 Bug fix ✨ Enhancement

Grey Divider

Walkthroughs

Description
• Prevents dismissal of forced-conversion sign-in sheet via swipe, tap-outside, or Hub close
  messages
• Adds isUserDismissalDisabled flag to BottomSheetViewController for locking sheet during
  conversion
• Implements lock release mechanism when user transitions from instant to verified auth level
• Maintains programmatic dismissal capability after successful authentication
Diagram
flowchart LR
  A["User enters instant state"] -->|requestSignInForcedConversion| B["Lock enabled<br/>isUserDismissalDisabled=true"]
  B -->|Disable swipe/tap-outside| C["Sheet non-dismissible"]
  C -->|Block Hub close messages| D["User must convert"]
  D -->|Auth level transitions<br/>to verified/unverified| E["Lock released<br/>isUserDismissalDisabled=false"]
  E -->|Auto-close proceeds| F["Sheet dismissed"]
Loading

Grey Divider

File Changes

1. Sources/Rownd/Rownd.swift ✨ Enhancement +11/-0

Add forced conversion lock control methods

• Added requestSignInForcedConversion(_:) method to enable the user dismissal lock before
 displaying sign-in sheet
• Added releaseForcedConversionLock() method to disable the lock after successful conversion
• Both methods marked with @MainActor for thread safety

Sources/Rownd/Rownd.swift


2. Sources/Rownd/Views/BottomSheetViewController.swift 🐞 Bug fix +16/-0

Implement dismissal lock in bottom sheet controller

• Added isUserDismissalDisabled boolean flag to track forced-conversion lock state
• Modified viewWillAppear to call setCanTouchDimmingBackgroundToDismiss(false) when lock is
 active
• Reset isUserDismissalDisabled to false in viewDidDisappear to prevent state leakage
• Updated hideBottomSheet() to reject dismissal requests when lock is active
• Modified canTouchDimmingBackgroundToDismiss() to prevent Hub from re-enabling dismissal while
 lock is active

Sources/Rownd/Views/BottomSheetViewController.swift


3. Sources/Rownd/Views/HubViewController.swift 🐞 Bug fix +7/-2

Block Hub-initiated dismissal during forced conversion

• Added check in hide() method to prevent Hub-initiated dismissal when forced-conversion lock is
 active
• Logs debug message when dismissal is blocked by active lock
• Maintains existing dismissal flow when lock is not active

Sources/Rownd/Views/HubViewController.swift


View more (1)
4. Sources/Rownd/framework/InstantUsers.swift ✨ Enhancement +21/-18

Monitor auth transitions and manage conversion lock lifecycle

• Added hasTriggeredConversion flag to track whether conversion flow has been initiated
• Changed from first operator to continuous sink subscription to monitor auth level changes
• Calls requestSignInForcedConversion() instead of requestSignIn() when entering instant state
• Implements lock release logic when user transitions from instant to verified/unverified/guest auth
 levels
• Treats .unknown auth level as transient state and does not release lock

Sources/Rownd/framework/InstantUsers.swift


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 12, 2026

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Lock not applied live 🐞 Bug ≡ Correctness
Description
requestSignInForcedConversion() sets isUserDismissalDisabled = true, but the actual bottom-sheet
dismissibility is only changed in BottomSheetViewController.viewWillAppear, so if the Hub is
already presented the sheet remains user-dismissible despite the lock.
Code

Sources/Rownd/Rownd.swift[R183-187]

+    @MainActor
+    internal static func requestSignInForcedConversion(_ signInOptions: RowndSignInOptions?) {
+        inst.bottomSheetController.isUserDismissalDisabled = true
+        requestSignIn(signInOptions)
+    }
Evidence
The lock is only enforced via setCanTouchDimmingBackgroundToDismiss(false) in viewWillAppear.
But displayViewControllerOnTop exits early if the bottom sheet is already presented, so calling
requestSignInForcedConversion while the Hub is already visible won’t trigger viewWillAppear and
the lock won’t be applied to the active sheetController.

Sources/Rownd/Rownd.swift[183-187]
Sources/Rownd/Views/BottomSheetViewController.swift[32-65]
Sources/Rownd/Rownd.swift[431-447]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Forced conversion sets a flag (`isUserDismissalDisabled`) but does not reliably apply it to an already-presented bottom sheet. The only place that calls `setCanTouchDimmingBackgroundToDismiss(false)` based on the flag is `BottomSheetViewController.viewWillAppear`, which won’t run again if the bottom sheet is already on-screen.
### Issue Context
`displayViewControllerOnTop` intentionally avoids re-presenting the bottom sheet when it’s already presented, so updating the lock must update the existing `sheetController` immediately.
### Fix Focus Areas
- Sources/Rownd/Rownd.swift[183-187]
- Sources/Rownd/Rownd.swift[431-447]
- Sources/Rownd/Views/BottomSheetViewController.swift[32-65]
### Suggested fix
Implement a `didSet` on `isUserDismissalDisabled` (or a dedicated method) that, when toggled, immediately calls `sheetController?.setCanTouchDimmingBackgroundToDismiss(...)` if `sheetController` already exists. This ensures the lock takes effect even when the Hub sheet is already presented.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Unlock doesn't restore dismissal ✓ Resolved 🐞 Bug ☼ Reliability
Description
releaseForcedConversionLock() clears only the boolean flag and never re-enables bottom-sheet user
dismissal on the active sheetController, so the current sheet can remain non-dismissible even
after the lock is released.
Code

Sources/Rownd/Rownd.swift[R189-192]

+    @MainActor
+    internal static func releaseForcedConversionLock() {
+        inst.bottomSheetController.isUserDismissalDisabled = false
+    }
Evidence
The PR introduces a one-way disable of dismissal (viewWillAppear forces
setCanTouchDimmingBackgroundToDismiss(false) when locked), but releaseForcedConversionLock()
only flips the boolean and never updates the existing sheetController state, so the dismissal
behavior can remain disabled until the sheet is recreated/dismissed.

Sources/Rownd/Rownd.swift[189-192]
Sources/Rownd/Views/BottomSheetViewController.swift[62-65]
Sources/Rownd/Views/BottomSheetViewController.swift[114-121]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When forced conversion ends, `releaseForcedConversionLock()` only sets `isUserDismissalDisabled = false` but does not restore `sheetController`’s dismissibility. Since the lock disables dismissal by calling `setCanTouchDimmingBackgroundToDismiss(false)`, the current sheet can stay non-dismissible even after unlock.
### Issue Context
Today, the only places that touch `setCanTouchDimmingBackgroundToDismiss` are:
- `viewWillAppear` (forces it off when locked)
- `canTouchDimmingBackgroundToDismiss(_:)` (passes through Hub requests, but blocks enable while locked)
There is no corresponding “unlock” path that restores the user-dismiss setting for the existing `sheetController`.
### Fix Focus Areas
- Sources/Rownd/Rownd.swift[189-192]
- Sources/Rownd/Views/BottomSheetViewController.swift[62-65]
- Sources/Rownd/Views/BottomSheetViewController.swift[114-121]
### Suggested fix
Track the desired dismissal state (e.g., `desiredCanTouchBackgroundToDismiss: Bool`, default `true`).
- In `canTouchDimmingBackgroundToDismiss(_:)`, always update `desiredCanTouchBackgroundToDismiss = enable`. If locked and enabling, don’t apply it immediately.
- When `isUserDismissalDisabled` toggles to `false` (unlock), apply `sheetController?.setCanTouchDimmingBackgroundToDismiss(desiredCanTouchBackgroundToDismiss)`.
This both restores behavior on unlock and avoids forcing `true` when the Hub explicitly wanted it disabled.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread Sources/Rownd/Rownd.swift
Comment on lines +183 to +187
@MainActor
internal static func requestSignInForcedConversion(_ signInOptions: RowndSignInOptions?) {
inst.bottomSheetController.isUserDismissalDisabled = true
requestSignIn(signInOptions)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Lock not applied live 🐞 Bug ≡ Correctness

requestSignInForcedConversion() sets isUserDismissalDisabled = true, but the actual bottom-sheet
dismissibility is only changed in BottomSheetViewController.viewWillAppear, so if the Hub is
already presented the sheet remains user-dismissible despite the lock.
Agent Prompt
### Issue description
Forced conversion sets a flag (`isUserDismissalDisabled`) but does not reliably apply it to an already-presented bottom sheet. The only place that calls `setCanTouchDimmingBackgroundToDismiss(false)` based on the flag is `BottomSheetViewController.viewWillAppear`, which won’t run again if the bottom sheet is already on-screen.

### Issue Context
`displayViewControllerOnTop` intentionally avoids re-presenting the bottom sheet when it’s already presented, so updating the lock must update the existing `sheetController` immediately.

### Fix Focus Areas
- Sources/Rownd/Rownd.swift[183-187]
- Sources/Rownd/Rownd.swift[431-447]
- Sources/Rownd/Views/BottomSheetViewController.swift[32-65]

### Suggested fix
Implement a `didSet` on `isUserDismissalDisabled` (or a dedicated method) that, when toggled, immediately calls `sheetController?.setCanTouchDimmingBackgroundToDismiss(...)` if `sheetController` already exists. This ensures the lock takes effect even when the Hub sheet is already presented.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 9ea56d0. isUserDismissalDisabled now has a didSet that pushes through to the live sheetController via setCanTouchDimmingBackgroundToDismiss(...), so toggling the flag after presentation takes effect immediately. Same path covers your second comment — releasing the lock restores the Hub's most recent dismissibility request (tracked in a separate hubRequestedCanTouchToDismiss so we don't unconditionally force true on unlock).

Copy link
Copy Markdown
Contributor Author

@mhamann mhamann left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Targeted, mostly contained fix. Two concerns I'd resolve before relying on this in production:

Correctness

  • A close path bypasses the lock — HubViewController.viewWillDisappear (line 145, unchanged in this PR) calls hostController.dismiss(animated: true) directly, sidestepping hideBottomSheet. Any system or programmatic event that disappears the Hub VC will close the sheet regardless of the lock. See line comment on HubViewController.swift:186.
  • Race between the post-auth 1.5s hide() timer and UserData.fetch propagating authLevel. If authLevel is still .instant when the timer fires, hide() is blocked; the lock-release later fires from the user-data update but nothing re-invokes hide(), so the sheet can stay open after a successful conversion. See line comment on InstantUsers.swift:56.

Design

  • .guest releases the lock alongside .verified/.unverified. That may or may not be what the customer wants — guest is still anonymous-ish. Worth a deliberate decision (line comment on InstantUsers.swift:56).
Nits
  • No unit tests added. InstantUsers has enough state-machine surface now (the hasTriggeredConversion gate, the dual transitions) to warrant a small test using a fake store.
  • BottomSheetViewController.hideBottomSheet drops the completion when locked. Today the only caller is HubViewController.hide() and we block above that path, so it doesn't fire — but it's a latent footgun (line comment on BottomSheetViewController.swift:89).
  • logger.debug for the blocked-dismissal messages is fine, but operators debugging "sheet won't close" reports will need to enable debug logging. Consider logger.log/info.

Landing recommendation: address the viewWillDisappear bypass and the close-race (a few lines each); merge after that.

}


if bottomSheetController.isUserDismissalDisabled {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug — lock bypass. The new check guards hide(), but HubViewController.viewWillDisappear at line 145 (unchanged here) directly calls:

hostController.dismiss(animated: true)

…on the bottom-sheet host. That path neither checks isUserDismissalDisabled nor goes through hideBottomSheet, so any system event that disappears the Hub VC will tear the sheet down despite the lock. Easy reproductions: presenting another modal over the Hub, deep-linking to a flow that swaps the sheet, or anything calling dismiss on a parent.

Suggest adding the same early-return on the lock at line 141, or routing all dismissals through hideBottomSheet so there's one chokepoint.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving as-is per offline discussion. The viewWillDisappear path requires something to disappear the Hub VC from outside the SDK, which our customers shouldn't be doing on the forced-conversion flow. Worth revisiting if we see field reports of escape via this path.


// User has converted to a non-instant auth level (verified, unverified, guest).
// Release the lock so the Hub's post-success auto-close can proceed.
if self.hasTriggeredConversion && authLevel != .instant && authLevel != .unknown {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race — sheet can stay open after successful conversion.

HubWebViewController dispatches the tokens on .authentication then schedules hide() 1.5s later:

store.dispatch(store.state.auth.onReceiveAuthTokens(
    AuthState(accessToken: authMessage.accessToken, refreshToken: authMessage.refreshToken)
))
...
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
    if initialJsFunctionArgsAsJson == self?.jsFunctionArgsAsJson {
        self?.hubViewController?.hide()
    }
}

The authLevel update comes from UserData.fetch(), not from the token dispatch. On a slow network (or any 1.5s+ fetch latency) the timeline is:

  1. t=0: tokens dispatched, authLevel still .instant
  2. t=1.5s: hide() fires → lock still set → blocked, sheet stays open
  3. t=2s+: UserData.fetch() returns, authLevel flips to .verified
  4. This sink fires, lock released — but nothing re-invokes hide(), so the sheet is now unlocked-but-still-open

The user sees a sheet that won't close on its own. They can swipe-dismiss it (because the lock is released), but that's an unexpected UX.

Suggest re-triggering dismissal when releasing the lock, e.g.:

Rownd.releaseForcedConversionLock()
// Re-attempt the post-auth close that may have been blocked by the lock.
(Rownd.inst.bottomSheetController.controller as? HubViewProtocol)?.hide()
subscriber.unsubscribe()

(Or add a releaseForcedConversionLockAndDismiss() helper that does both.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ea56d0. Rownd.releaseForcedConversionLock now re-invokes hide() on the active HubViewProtocol after clearing the flag, so if the post-auth hide() timer fired during the locked window and was blocked, we close the sheet as soon as authLevel propagates and the lock releases. Guarded on wasLocked so calling release with no lock held is a no-op.


// User has converted to a non-instant auth level (verified, unverified, guest).
// Release the lock so the Hub's post-success auto-close can proceed.
if self.hasTriggeredConversion && authLevel != .instant && authLevel != .unknown {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question — .guest releases the lock. Going .instant.guest isn't really "adding an identifier" in the spirit of forceInstantUserConversion. If a customer's Hub config exposes a "continue as guest" path, the user could escape the forced flow without converting. Is that the intended behavior, or should the release condition be authLevel == .verified || authLevel == .unverified only?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping current behavior (.guest releases the lock alongside .verified/.unverified). The contract of forceInstantUserConversion is "force the user out of the auto-assigned .instant state" — .guest is a deliberate user choice and treating it as a successful exit avoids stranding users when a customer's Hub config exposes a guest path. If a customer wants stricter enforcement, the right knob is disabling guest auth in Hub config.

}

public func hideBottomSheet(_ completion: (() -> Void)? = nil) {
if isUserDismissalDisabled {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit — completion silently dropped. When locked, the completion parameter is never invoked. Today the only caller is HubViewController.hide(), which is itself blocked one level up, so this doesn't fire in practice. But the signature still promises "call me back when the sheet is hidden," and a future caller could quietly wedge if they depend on that. Worth a doc comment at minimum, or have callers check isUserDismissalDisabled themselves and skip passing the completion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving as-is. The only caller passing a completion (HubViewController.hide()) is itself guarded by the lock check one level up, so the completion is never registered when the inner block fires. Worth revisiting if a second caller appears.

Addresses review feedback from PR #125:

- BottomSheetViewController: `isUserDismissalDisabled` now has a `didSet`
  that updates the live `sheetController` so the lock takes effect on an
  already-presented sheet. Track the Hub's last-requested dismissibility
  separately so releasing the lock restores it rather than unconditionally
  re-enabling (Qodo #1, #2).
- Rownd: `releaseForcedConversionLock` now re-invokes `hide()` on the
  active HubViewProtocol so a post-auth auto-close that lost the race
  against `UserData.fetch` propagating `authLevel` still closes the sheet.
  Guarded on the prior locked state so unlocking a non-held lock is a
  no-op (avoids side effects in tests).
- Add `Rownd._bottomSheetIsLocked` internal accessor for tests.
- Tests: cover lock engagement on `.instant`, release on `.verified`,
  and the once-per-session gate after release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mhamann mhamann merged commit 8731aac into main May 12, 2026
3 checks passed
@mhamann mhamann deleted the fix/instant-user-conversion-non-dismissible branch May 12, 2026 19:20
mhamann added a commit that referenced this pull request May 12, 2026
* fix(instant): make forced conversion sheet non-dismissible

When `forceInstantUserConversion` is enabled, the sign-in sheet must be
non-dismissible until the user actually adds an identifier. Previously
the sheet defaulted to dismissible via swipe, tap-outside, and any Hub
close message, which left customers with large populations of unconverted
instant users.

- BottomSheetViewController gains an `isUserDismissalDisabled` flag that
  disables swipe-to-dismiss and tap-outside-to-dismiss at presentation,
  and that the Hub's `can_touch_background_to_dismiss` message cannot
  re-enable.
- `hideBottomSheet` and `HubViewController.hide()` honor the same lock,
  so Hub-dispatched `closeHubViewController`/`signOut` messages cannot
  close the sheet either.
- InstantUsers observes the post-conversion auth-level transition and
  releases the lock once the user is no longer `.instant`, so the
  standard post-auth auto-close path still runs.



* fix(instant): apply lock live and re-trigger close on release

Addresses review feedback from PR #125:

- BottomSheetViewController: `isUserDismissalDisabled` now has a `didSet`
  that updates the live `sheetController` so the lock takes effect on an
  already-presented sheet. Track the Hub's last-requested dismissibility
  separately so releasing the lock restores it rather than unconditionally
  re-enabling (Qodo #1, #2).
- Rownd: `releaseForcedConversionLock` now re-invokes `hide()` on the
  active HubViewProtocol so a post-auth auto-close that lost the race
  against `UserData.fetch` propagating `authLevel` still closes the sheet.
  Guarded on the prior locked state so unlocking a non-held lock is a
  no-op (avoids side effects in tests).
- Add `Rownd._bottomSheetIsLocked` internal accessor for tests.
- Tests: cover lock engagement on `.instant`, release on `.verified`,
  and the once-per-session gate after release.



---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant